【Unity记录】如何优雅地在Unity中订阅与退订C#事件

您所在的位置:网站首页 gdcvault 怎么订阅 【Unity记录】如何优雅地在Unity中订阅与退订C#事件

【Unity记录】如何优雅地在Unity中订阅与退订C#事件

2023-09-19 18:08| 来源: 网络整理| 查看: 265

本文内容

阅读须知:

阅读本文建议提前了解C#事件系统本文侧重介绍在Unity中事件退订的做法

本文将介绍:

简单介绍何为事件简单介绍如何使用C#事件为何需要退订C#事件何时需要退订C#事件如何在Unity中优雅地退订C#事件 事件订阅模型 简介

事件模型是面向对象编程中常用的一种模块间通信的模型,其通过事件通知取代传统低效的轮询进行模块间逻辑交互。 在编写程序的过程中,经常会有如下情景:模块A需要在模块B的某些时刻执行相应代码,那应该如何如何获悉模块B处于这些特定时刻呢?

传统做法:A主动轮询: 模块A按一定时间(一帧?一毫秒?)对模块B进行状态检测,如果达到该状态,则执行对应逻辑。 这样的做法有两个很大的缺陷:

无法在状态达成的瞬间触发占用大量无效处理时间

更佳做法:A等待通知: A提前向B订阅该事件,通俗来讲,就是A对B说:“你在达成XX条件的时候通知我一哈”。而B在达成该条件时,主动告知A,使A获得一个执行逻辑的机会。 这个做法就完全解决了上述轮询的两个缺陷;此外。事件模型还有利于模块间的解耦,增加了逻辑之间的合理性。

C#委托与事件

如果你已经对事件的概念很熟悉可以跳过本节

扼要

委托与事件是C#中为数不多的新人杀手,但其实它们的实质并没有那么复杂。本文不会展开详细解释它们的原理与区别,只扼要介绍它们的概念。如果需要详细解释,请阅读其它文章。

简单来讲:

C#中的委托的作用可类比于其它语言的“函数指针”事件是封装后的委托,除了访问级别与委托不同以外,作用基本相同。 委托

下面这个例子展示了委托的作用。其含义是A希望监测B的血量,当B血量变化时告知A。

using System; public class B { private float hp; public float Hp { get => hp; set { if (hp != value) { hp = value; //?.写法相当于null检测,如果HpChanged为空则不执行 //B此处触发委托 HpChanged?.Invoke(hp); } } } /// /// 定义了名为ConditionChangeHandler的委托,其签名需要一个float类型的参数 /// 相当于定义了一个参数为float类型的“函数指针类型” /// /// 传入的数值 public delegate void ConditionChangeHandler(float value); /// /// 定义了上述委托的一个实际函数指针 /// 但在C#中更为强大,其可以同时指向0~N个函数逻辑 /// public ConditionChangeHandler HpChanged; } public class A { /// /// A这个函数用于监控B的Hp /// /// public void ObserveHp(float hp) { Console.WriteLine($"哈哈!这个B只剩{hp}血了"); } } public class Test { public static void Main() { A a = new A(); B b = new B() { Hp = 10086}; //B初始化10086血量 //此处a订阅了b的 b.HpChanged += a.ObserveHp; //B受到一万点伤害 b.Hp -= 10000; } }

输出:

哈哈!这个B只剩86血了

A : 其中ConditionChangeHandler是一个委托类型,其规定了委托处理方法的签名(所谓函数指针的类型) HpChanged是该类型具体的一个委托(所谓函数指针威力加强版) B : ObserveHp是欲检测A的处理方法 **Main: ** b.HpChanged += a.ObserveHp; 该句“订阅”了委托,建立了A、B之间的沟通关系。

事件 委托的缺陷

上面的委托已经足够好用了,但其存在一个问题:谁都可以在任意时刻调用HpChanged,这样的结果是容易造成代码的误用,考虑某个与你合作的程序员,他并不清楚B的具体逻辑,但他阴差阳错写出了这样的玩意:

public class Test { public static void Main() { A a = new A(); B b = new B() { Hp = 10086}; //B初始化10086血量 //此处a订阅了b的 b.HpChanged += a.ObserveHp; //其它人,或者自己误用的代码 b.HpChanged(114514); //B受到一万点伤害 b.Hp -= 10000; } }

输出:

哈哈!这个B只剩114514血了 哈哈!这个B只剩86血了

可见,其中误用的代码: b.HpChanged(114514); 带来的输出是与B真实血量完全无关的“哈哈!这个B只剩114514血了”,这样就完全违背了设立这个委托的初衷。

实际应用中,这种错误屌用造成的影响会非常诡异,Debug的时候,经常就是啪的一下很快啊,一天过去了😅。 所以有没有能避免这种情况的做法涅?答案就是事件啦。

事件定义

事件事实上只对委托的访问性进行了封装,因此改为事件十分简单,只需要在委托的基础上加入event关键词:

/// /// 加入了event关键词,其余代码均保持原样 /// public event ConditionChangeHandler HpChanged;

此时在类外仍可照常订阅退订事件,但只有在事件订阅的类内才可以触发事件,如果企图在类外的任意位置调用事件(比如上面提及到的误用),则会出现编译错误:

error CS0070: The event 'B.HpChanged' can only appear on the left hand side of += or -= (except when used from within the type 'B') C#内置委托类型

C#给我们提供了多个泛型的委托类型,我们可以使用它们,而无须自定义委托类型。它们分别是Action以及Func,有兴趣的朋友可以了解一下。

事件的退订 为什么需要退订事件

事件爽归爽,但是爽完是需要负责任的涅。

内存泄漏风险

在C++中,我们知道手动分配的内存需要使用完毕后归还,在C#中得益于GC(垃圾回收)的机制,使我们无须再关注内存的分配问题,直接用就完事儿了反正不会内存泄漏。

但是!事件如果不退订,存在内存泄漏的风险: 如果事件广播方是一个生命周期极长的对象,而订阅方是一大组生命周期极短的对象。在订阅方生命周期结束后,其GC并不会回收其存储于广播方的事件订阅,也就是广播方会一直存储这些没用的订阅,因此便存在内存泄漏甚至溢出的风险。

残余错误风险

上面的例子提及,广播方会一直存储没用的事件,这还不算最要命的,要命的是,它在广播时仍然会触发这些事件订阅!这就麻烦了,尝试触发一些超出生命周期的逻辑往往造成错误,运气不好可能啪的一下又一天过去了。

所以请务必重视时间退订!(血的教训家人们)

什么情况需要退订事件

显然,当事件广播方生命周期极长时需要订阅方在完事后退订事件;反之,如果事件广播方生命周期短,甚至不及订阅方,则无须退订啦,因为在广播方终结后的GC中会清理掉所有事件订阅的。

如何退订事件?

退订事件需要借助订阅时的引用,所以如果需要退订事件,请将事件处理函数的引用缓存至合适的位置:

//Lambda表达式,无法退订 b.HpChanged += (hp) => {Console.WriteLine(hp);}; //该类型的委托,可以退订 B.ConditionChangeHandler exp = (hp) => {Console.WriteLine(hp);}; b.HpChanged += exp; b.HpChanged -= exp; //签名吻合的方法:可以退订 b.HpChanged += a.ObserveHp; b.HpChanged -= a.ObserveHp;

总之只要你确保能够取得该订阅的引用,就可以在适当的地方退订了。

何时退订事件?

在订阅方不再需要接收广播方事件推送时退订,你所需要做的只是写一句-= ……废话,有这种时机那肯定好啊,那要是没有呢?比如,订阅方需要在整个生命周期内订阅事件

那就在它寄的时候退订吧! 具体做法是:

在一般C#类中(非继承MonoBehavior)

跟C++释放内存的做法类似,可以在C#中的终结器(Finalizers,类似于C++析构函数)中处理退订逻辑。具体做法可以参考下面这个例子:

public class B { /*...B的逻辑不变...*/ } public class A { B cachedB; /// /// 定义一个A的初始化函数 /// 为了可以退订,我们需要保存广播方的引用 /// 顺便把事件订阅也一并封装进来 /// public void Initialize(B b) { cachedB = b; cachedB.HpChanged += ObserveHp; } /// /// A这个函数用于监控B的Hp /// /// public void ObserveHp(float hp) { Console.WriteLine($"哈哈!这个B只剩{hp}血了"); } /// /// 终结器退订事件 /// ~A() { Console.WriteLine($"执行到A.Finalizer"); cachedB.HpChanged -= ObserveHp; } } public class Test { public static void Main() { A a = new A(); B b = new B() { Hp = 10086}; //B初始化10086血量 a.Initialize(b); //B受到一万点伤害 b.Hp -= 10000; } } 在Unity的MonoBehavior类中 不推荐做法:终结器 (Finalizer)

众所周知Unity喜欢搞特殊,在MonoBehavior类中,构造函数与终结器会不可控地被Unity调用若干次,具体可以查看我之前写的一篇文章,因此不建议在终结器中退订事件

深坑做法:MonoBehavior.OnDestroy

好在Unity给我们留下了许多实用的生命周期方法……以及一堆坑! 一个错误的(至少是有隐患的)做法是在MonoBehavior.OnDestroy方法中退订事件。因为当某个物体在场上处于未启用状态时被销毁,它的OnDestroy是不会被调用的!!! 具体而言是在以下几种操作时OnDestroy不会调用:

对该物体或其父物体调用SetActive(false);对该脚本设置了enabled = false;在运行时的Inspector中禁用了组件或其附属物体(或父物体)

我遇到的问题是,我订阅事件的物体处于未启用状态,此时加载切换至另一场景(自然销毁了所有订阅方,但OnDestroy未被调用,即未正常退订事件)。另一方面,我的广播方是存在于全局跨场景的对象,因此当它再广播该事件时,残余的事件订阅引发了一连串的错误,查错的时候啪的一下很快啊,一天又过去了😅

推荐做法:MonoBehavior.OnDisable

为了避免上述问题,一种常用的做法是在组件禁用时就退订事件,当组件启用时再重新订阅事件。 OnDisable恰好在上述的任意OnDestroy不会调用的情况下,均可照常调用,覆盖范围更大更省心,虽然如果频繁禁用启用物体会导致开销增大,但就这一点点开销换来自己阳寿,肯定是值得的。

但这种做法会在第一次启用前有些小问题(因为由Instantiate生成的物体可能还没初始化完毕),因此如果需要初始化,可以考虑在物体生成时保持禁用状态,当初始化结束后再启用物体,具体请看以下示意:

public class A : MonoBehaviour { B cachedB; private void Awake() { // 先禁用该物体 gameObject.SetActive(false); } /// /// 处理第一次启用时的特殊情况 /// /// public void Initialize(B b) { // ...此处做初始化工作 // 赋值 cachedB = b; // 启用该物体 gameObject.SetActive(true); } private void ObserveHp(float hp) { Console.WriteLine($"哈哈!这个B只剩{hp}血了"); } private void OnEnable() { cachedB.HpChanged += ObserveHp; } private void OnDisable() { if (cachedB != null) { cachedB.HpChanged -= ObserveHp; } } }

注意: 如果要操作A脚本所在物体的父物体的启用,请确保先调用A.Initialize(B)初始化后,再启用其父物体,否则可能会在错误的cachedB引用下调用OnEnable()方法

在OnEnable与OnDisable进行事件订阅退订是Unity中的常用做法,希望这个例子可以给你一些启示。如果有其它逻辑需要实现,那就需要具体问题具体分析了。

参考 Is OnDestroy reliable in Unity?Will loading a new scene automatically destroy/unsubscribe event handlers?


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3